Skip to content

Add hosted captun.sh safety controls#20

Closed
mmkal wants to merge 17 commits into
hosted-captun-shfrom
mmkal/26/05/24/hosted-admission-module
Closed

Add hosted captun.sh safety controls#20
mmkal wants to merge 17 commits into
hosted-captun-shfrom
mmkal/26/05/24/hosted-admission-module

Conversation

@mmkal
Copy link
Copy Markdown
Contributor

@mmkal mmkal commented May 23, 2026

Summary

Adds the first hosted captun.sh safety layer on top of the initial public tunnel deployment.

This collapses the earlier stacked safety PRs into one review surface. It supersedes #17, #18, and #19.

Behavior

Hosted captun.sh now gets:

  • fixed-window rate limits for connect attempts by client IP
  • fixed-window rate limits for forwarded requests by client IP and tunnel name
  • fail-closed behavior if the hosted rate limiter Durable Object binding is missing, unless HOSTED_RATE_LIMIT_DISABLED=1 is explicitly set
  • active-session ownership tokens for anonymous hosted tunnels
  • same-owner reconnect/replace support via ownerToken
  • CLI retry loops that reuse one anonymous owner token per tunnel session
  • 409 Conflict instead of anonymous clients evicting each other from an active tunnel name
  • clearer library and CLI messages for the known hosted name-in-use conflict
  • read-only connect diagnostics, so rejection probes do not accept/replace tunnels, do not charge the connect rate-limit bucket a second time, and still surface fail-closed or recent rate-limit rejections
  • a direct-testable hosted admission module so auth/token/conflict policy is no longer embedded in the Durable Object lifecycle code

Self-hosted and secret-protected deployments keep the existing replacement/auth behavior.

User-Facing API

createCaptunTunnel now returns the anonymous hosted owner token and accepts it for intentional same-owner replacement:

const first = await createCaptunTunnel({
  name: "demo",
  fetch: () => new Response("first"),
});

const replacement = await createCaptunTunnel({
  name: "demo",
  ownerToken: first.ownerToken,
  fetch: () => new Response("replacement"),
});

A different active anonymous owner gets a conflict instead of evicting the current tunnel.

Runtime Configuration

Defaults:

  • HOSTED_RATE_LIMIT_WINDOW_SECONDS=60
  • HOSTED_CONNECTS_PER_IP_PER_WINDOW=30
  • HOSTED_REQUESTS_PER_IP_PER_WINDOW=600
  • HOSTED_REQUESTS_PER_TUNNEL_PER_WINDOW=1200

The hosted rate limiter is wired through a new HostedRateLimiter Durable Object binding and migration.

Follow-Up Left Out Intentionally

This is still intentionally lightweight. It does not add persistent reservations, auth/accounts, paid custom names, active tunnel caps, byte/response caps, observability dashboards, or Cloudflare-native edge throttles. Anonymous ownership is active-session only: once a tunnel disconnects, the name is free for another anonymous token to claim.

Verification

  • pnpm exec vitest run test/hosted-admission.test.ts test/worker.test.ts test/cli.test.ts --testNamePattern 'diagnostic|retries reuse|secret|rate limit|ownership|owner token'
  • pnpm run check
  • pnpm test
  • pnpm run build
  • CAPTUN_PUBLIC_E2E=1 pnpm vitest run test/public-hosted.test.ts was run on the earlier safety stack before the Bugbot-only fixes

Note

High Risk
Adds enforcement and throttling to the hosted tunnel connect/request path (new Durable Object rate limiter, ownership-token admission, and diagnostic probing), which can affect availability and connection behavior for production traffic. Also changes the public client API surface (ownerToken), so regressions could break existing consumers if assumptions differ.

Overview
Introduces hosted-only safety controls for captun.sh: anonymous tunnel connects now require an ownership token and reject conflicting active owners with 409 instead of evicting the current session, while self-hosted/secret-protected deployments keep prior replace/auth behavior.

Adds Durable Object–backed fixed-window rate limiting for hosted connect attempts (per IP) and forwarded requests (per IP and per tunnel), including fail-closed behavior when the limiter binding is missing (with an explicit HOSTED_RATE_LIMIT_DISABLED=1 escape hatch).

Improves client-side ergonomics and diagnostics: createCaptunTunnel now generates/returns/accepts ownerToken, performs a short read-only HTTP probe to surface WebSocket upgrade rejection details (CaptunTunnelConnectError), and the CLI reuses one token across retries and shows a clearer message for the known hosted name-in-use conflict.

Reviewed by Cursor Bugbot for commit 4377903. Bugbot is set up for automated code reviews on this repo. Configure here.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 23, 2026

Open in StackBlitz

npx https://pkg.pr.new/captun@20

commit: 4377903

@mmkal mmkal changed the title [bedtime-architecture] Extract hosted tunnel admission policy Add hosted captun.sh safety controls May 24, 2026
@mmkal mmkal changed the base branch from mmkal/26/05/24/hosted-connect-conflict-message to hosted-captun-sh May 24, 2026 06:36
Comment thread src/index.ts
Comment thread src/cli/bin.ts
Comment thread src/index.ts
Comment thread src/cli/bin.ts Outdated
Comment thread src/index.ts
Comment thread src/hosted-admission.ts
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4377903. Configure here.

Comment thread src/index.ts
() =>
settle(() => {
void webSocketConnectionFailedError(options).then(reject);
}),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Close handler skips rejection probe

Medium Severity

In waitUntilOpen, the close listener rejects immediately with a generic close message, while the diagnostic HTTP probe that surfaces 409/rate-limit bodies runs only from the error listener. If close fires first (common when the upgrade handshake fails), CaptunTunnelConnectError and CLI active-name conflict hints are skipped.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4377903. Configure here.

Comment thread src/worker.ts
windowSeconds: config.windowSeconds,
});
if (!result.ok) return hostedRateLimitedResponse(result);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rate limit counts before rejection

Medium Severity

Hosted forwarded-request limiting runs separate HostedRateLimiter.check calls for the client IP and tunnel name in sequence. Each successful check increments its bucket before the next runs, so a request rejected on the tunnel limit still consumes IP quota, and vice versa, without the request being forwarded.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4377903. Configure here.

@mmkal
Copy link
Copy Markdown
Contributor Author

mmkal commented May 25, 2026

Superseded by the gateway-owned addressing refactor now pushed to #16. The hosted safety/rate-limit work should be rebuilt on top of the new gateway/token/connect-query protocol instead of carrying forward ownerToken/serverUrl-era API shape.

@mmkal mmkal closed this May 25, 2026
@mmkal
Copy link
Copy Markdown
Contributor Author

mmkal commented May 26, 2026

🤖 Recreated this on top of #16 as #22.

mmkal added a commit that referenced this pull request May 26, 2026
## Summary

Adds the hosted `captun.sh` path so a first-time user can get a public
tunnel without deploying anything first.

```bash
# with something listening on localhost:3000
npx captun 3000
```

The CLI now defaults to the hosted `https://captun.sh` gateway when
there is no local config, generates a random tunnel name, and prints a
public URL like:

```text
https://abc123.captun.sh -> localhost:3000
```

No Cloudflare account, config file, token, or deploy wizard is needed
for that first run. Users can still run `npx captun deploy` later if
they want their own self-hosted gateway.

## What Changed

- defaults missing local config to the hosted `https://captun.sh`
gateway
- changes `createCaptunTunnel` to connect with `{ gateway, name, token,
fetch }` and wait for the gateway to return `{ url, token }`
- renames the low-level Cap'n Web accept APIs to
`acceptFetcherCapability` and `acceptFetcherCapabilityFromSocket`
- updates the CLI, deploy wizard, config file, smoke scripts,
benchmarks, docs, and hosted browser demo to use `gateway`/`token`
- keeps the self-hosted Cloudflare Tunnel Gateway readable in
`src/worker.ts`, with the hosted `captun.sh` product surface isolated
under `src/hosted/`
- reserves product/control-plane tunnel names, serves `www.captun.sh`,
and redirects the apex host to `www`
- adds `CONTEXT.md` and ADR-0001 to keep the Fetcher Capability / Tunnel
/ Gateway language clear

#20 is superseded by this shape; hosted rate limiting and ownership
controls are rebuilt in #22 on top of the gateway-owned protocol.

## Library Example

```ts
import { createCaptunTunnel } from "captun";

const tunnel = await createCaptunTunnel({
  fetch: (request) => Response.json({ path: new URL(request.url).pathname }),
});

console.log(tunnel.url); // https://abc123.captun.sh
```

Self-hosted use now passes the gateway URL, not a tunnel URL template:

```bash
npx captun 3000 --gateway 'https://captun.youraccount.workers.dev' --token abc123
```

## Structure

- `src/index.ts` contains the client API and low-level Fetcher
Capability helpers.
- `src/worker.ts` is the deployable self-hosted Cloudflare Tunnel
Gateway.
- `src/hosted/site.ts` and `src/hosted/worker.ts` are the
Iterate-operated hosted surface for `captun.sh`.
- `wrangler.jsonc` is for self-hosted deployment;
`wrangler.hosted.jsonc` is for the hosted service.

## Verification

- `pnpm run check`
- `pnpm test`
- `pnpm run build`
- `pnpm exec vitest run test/worker.test.ts test/e2e.test.ts
examples/weather-reporter/e2e.test.ts`
- `CAPTUN_PUBLIC_E2E=1 pnpm exec vitest run test/public-hosted.test.ts`
- deployed `captun-public` to Iterate prd with `captun.sh/*` and
`*.captun.sh/*`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant